第二十天終於到了,這同時也表示旅程也將到終點了,照我的規劃,剩下的天數應該完全足夠把整個專案完成,並在最後一天做個總回顧跟一些可能的發展方向,理論上應該能提出一個完整的架構讓任何人都能打造自己的 AI 專案!聽起來是挺美好的,但我們還是先把目光放在眼前的課題吧!
我們昨天處理掉幾個讓我從第二週開始就有點煩心的問題,在基本的使用體驗上應該比之前好一些了,雖然我還有觀察到幾個問題,但這部分我們可以留到最後一週再處理即可,我們今天要做的事情相對更單純一些,我們要對一個後端 API 做重構,也就是我們目前最核心的功能所在/api/interview/evaluate/route.ts
,這個 API確實功能齊全,但同時它也已經變得有些臃腫。它就像一個過於熱心的專案經理,一個人包辦了所有事情:呼叫 RAG、執行程式碼、建立 Prompt、跟 AI 溝通... 所有的邏輯都擠在同一個檔案裡。雖然現在它還能運作,但我們可以預見,當下週要加入「使用者驗證」和「儲存練習紀錄」等新功能時,這個檔案將會變成一頭難以維護的巨獸。俗話說得好:「先整理房間,再買新傢俱。」今天,我們就要來當一次整理師,在加入新功能之前,對這個核心 API 進行一次徹底的「模組化重構」。
今天不加新功能,我們的目標很單純:將 /api/interview/evaluate/route.ts
拆分成多個職責單一、易於管理的小模組,同時也將其他散落的邏輯整理起來。
lib
資料夾:讓這個工具箱有更多工具可用。route.ts
:讓它回歸單純的「交通指揮」角色,只負責協調不同模組,而不是親自下場做所有事。我們在之前的設計中就有建立lib
資料夾,當前的檔案應該只有mockData.ts
,但之後它將是我們放置其他可重用、跨功能程式碼的地方。
我們的計畫是將 evaluate/route.ts
的功能拆分到以下幾個新檔案中:
app/lib/utils.ts
:放置通用的輔助函式,例如 retryAsyncFunction
。app/lib/supabase.ts
:專門處理與 Supabase 相關的一切,包括 RAG 搜尋。app/lib/judge0.ts
:負責呼叫 Judge0 代理並取得程式碼執行結果,其中也會包含我們 judge0/execute/route.ts
的邏輯整合。app/lib/prompt.ts
:集中管理我們的 Prompt 模板和建構邏輯。app/lib/gemini.ts
:封裝與 Google Gemini API 的所有互動。這樣的結構能讓我們的專案一目了然,當未來需要修改 RAG 邏輯時,我們就知道要去 supabase.ts
找,而不用在巨大的 route.ts 裡大海撈針,也許你會覺得其中一些檔案不是這麼的必要,例如judge0.ts
& gemini.ts
這種已經有一個專用的 route.ts
的路由的情境,但實際上服務(service)本身與本來就應該與路由的邏輯分開,在路由內去呼叫服務而不是將所有邏輯都塞在路由中,即便在 POC 的階段看起來沒有這麼必要,但對於一個日漸膨脹的專案來說,職責的劃分會讓一切都更輕鬆點。
這個檔案目前的內容會比較單純,我們現階段其實沒有用到太多的通用函數,因此你只要將原本在evalute/route.ts
的兩個功能函數拿出來寫到該檔案即可,完整的檔案內容如以下:
import { ChatMessage } from '@/app/types/interview';
/**
* 通用的非同步函式重試機制,具備指數退避策略。
* @param asyncFn 要執行的非同步函式
* @param retries 重試次數,預設 3 次
* @param delay 初始延遲時間 (ms),預設 1000ms
* @param onRetry 每次重試時呼叫的回呼函式
* @returns 非同步函式的執行結果
*/
export async function retryAsyncFunction<T>(
asyncFn: () => Promise<T>,
retries = 3,
delay = 1000,
onRetry?: (error: Error, attempt: number) => void
): Promise<T> {
for (let i = 0; i < retries; i++) {
try {
return await asyncFn();
} catch (error) {
if (onRetry) {
onRetry(error as Error, i + 1);
}
if (i === retries - 1) throw error;
await new Promise((res) => setTimeout(res, delay * Math.pow(2, i)));
}
}
// 迴圈結束後還是失敗,拋出錯誤 (理論上不會執行到這裡,但為求型別安全)
throw new Error('Retry failed after multiple attempts.');
}
/**
* 將對話歷史格式化為純文字,並只取最近的訊息。
* @param history 聊天訊息陣列
* @returns 格式化後的純文字歷史紀錄
*/
export function formatChatHistory(history: ChatMessage[]): string {
if (!history || history.length === 0) {
return '無歷史對話紀錄。';
}
// 只取最近的 4 則訊息 (約 2 輪對話),避免 Prompt 過長
const recentHistory = history.slice(-4);
return recentHistory
.map((msg) => {
const prefix = msg.role === 'user' ? 'User' : 'AI';
// 我們只關心對話內容,忽略 evaluation 物件
return `${prefix}: ${msg.content}`;
})
.join('\n');
}
/**
* 等待指定的毫秒數。
* @param ms 等待的毫秒數
* @returns 一個 Promise,在指定的時間後會被解決
*/
export const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
之後若是有什麼通用函數,例如一些陣列處理、分組或是深拷貝之類的都可以往這丟。
下一步我們也是按照類似的邏輯,在原本在evaluate.ts
檔案中的各種服務拆分出來,其實就是複製貼上後再加點註解而已,簡單!
supabase.ts
將原本相關的邏輯抽出後,用一個performRagSearch
函數輸出這串邏輯,方便之餘也增了加了一點可讀性。
import { createClient } from '@supabase/supabase-js';
// 初始化 Supabase client,確保只在伺服器端使用 service key
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
/**
* 執行 RAG 語意搜尋。
* @param embedding 使用者回答的向量
* @param questionId 目標問題 ID
* @returns 相關的知識點內容
*/
export async function performRagSearch(
embedding: number[],
questionId: string
): Promise<string> {
const { data, error } = await supabase.rpc('match_documents', {
query_embedding: embedding,
match_threshold: 0.7,
match_count: 5,
p_question_id: questionId,
});
if (error) {
console.error('Supabase RAG search error:', error);
// 在發生錯誤時回傳一個友善的訊息,而不是讓整個請求失敗
return '知識庫查詢失敗,請稍後再試。';
}
return data?.length > 0
? data.map((d: { content: string }) => `- ${d.content}`).join('\n')
: '在知識庫中找不到相關的參考資料。';
}
judge0.ts
同樣也是將原本相關的邏輯抽出後,用一個executeCode
函數輸出這串邏輯,另一個額外的步驟則是我們也要到app/api/judge0/execute/route.ts
中把相關的邏輯也一並抽出後整理到這個檔案中,完整的檔案內容如下:
// app/lib/judge0.ts
import { retryAsyncFunction, sleep } from './utils'; // 從 utils 引入需要的函數
// 為了更清晰的型別定義,我們可以為 Judge0 的回傳結果建立一個 interface
export interface Judge0ExecutionResult {
stdout: string | null;
stderr: string | null;
compile_output: string | null;
status: {
id: number;
description: string;
};
}
const JUDGE0_API_HOST = process.env.JUDGE0_API_HOST;
const JUDGE0_API_KEY = process.env.JUDGE0_API_KEY;
const JAVASCRIPT_LANGUAGE_ID = 93;
/**
* 【核心引擎】執行程式碼並取得結構化的 JSON 結果。
* 這個函式處理與 Judge0 API 的直接通訊、輪詢和解碼。
* @param source_code 要執行的原始碼
* @returns 包含執行結果的物件
* @throws 如果環境變數未設定、API 呼叫失敗或超時,則拋出錯誤
*/
export async function executeCode(
source_code: string
): Promise<Judge0ExecutionResult> {
if (!JUDGE0_API_HOST || !JUDGE0_API_KEY) {
console.error('Judge0 API environment variables are not set.');
throw new Error('Judge0 服務設定不完整。');
}
// Step 1: 提交程式碼
const encodedSourceCode = Buffer.from(source_code).toString('base64');
const submissionResponse = await fetch(
`https://${JUDGE0_API_HOST}/submissions?base64_encoded=true&wait=false`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-RapidAPI-Key': JUDGE0_API_KEY,
'X-RapidAPI-Host': JUDGE0_API_HOST,
},
body: JSON.stringify({
source_code: encodedSourceCode,
language_id: JAVASCRIPT_LANGUAGE_ID,
}),
}
);
if (!submissionResponse.ok) {
const errorText = await submissionResponse.text();
throw new Error(`提交至 Judge0 失敗: ${errorText}`);
}
const { token } = await submissionResponse.json();
if (!token) throw new Error('無法從 Judge0 取得 Token');
// Step 2: 輪詢結果
let resultData;
for (let i = 0; i < 10; i++) {
await sleep(500);
const resultResponse = await fetch(
`https://${JUDGE0_API_HOST}/submissions/${token}?base64_encoded=true`,
{
method: 'GET',
headers: {
'X-RapidAPI-Key': JUDGE0_API_KEY,
'X-RapidAPI-Host': JUDGE0_API_HOST,
},
}
);
if (!resultResponse.ok) continue; // 如果輪詢失敗,繼續嘗試
resultData = await resultResponse.json();
if (resultData.status_id > 2) break; // 執行完成
}
if (!resultData || resultData.status_id <= 2) {
throw new Error('程式碼執行超時');
}
// Step 3: 解碼並回傳
return {
...resultData,
stdout: resultData.stdout
? Buffer.from(resultData.stdout, 'base64').toString('utf-8')
: null,
stderr: resultData.stderr
? Buffer.from(resultData.stderr, 'base64').toString('utf-8')
: null,
compile_output: resultData.compile_output
? Buffer.from(resultData.compile_output, 'base64').toString('utf-8')
: null,
};
}
/**
* 【對外接口 for Evaluate API】執行程式碼並回傳格式化後的純文字結果。
* 這個函式封裝了重試邏輯和結果格式化,專門給 Prompt 使用。
* @param code 使用者提交的原始碼
* @returns 格式化後的執行結果字串
*/
export async function getFormattedJudge0Result(code: string): Promise<string> {
try {
// 使用 retryAsyncFunction 來增加呼叫核心引擎的穩健性
const result = await retryAsyncFunction(
() => executeCode(code),
3,
1000,
(error, attempt) =>
console.warn(
`Judge0 execution attempt ${attempt} failed: ${error.message}`
)
);
// 將成功的結果格式化為 Prompt 需要的字串
return `Status: ${result.status?.description || 'N/A'}\nStdout: ${
result.stdout || 'N/A'
}\nStderr: ${result.stderr || 'N/A'}`;
} catch (error) {
console.error('Judge0 execution failed after all retries:', error);
// 如果最終失敗,回傳一個友善的錯誤訊息給 Prompt
return '程式碼執行服務暫時無法連線,無法取得客觀執行結果。';
}
}
prompt.ts
這個檔案嚴格說起來不算一個服務,你可以自己選擇要不要抽出這層邏輯,我只是單純想整理乾淨一些,也許過幾天我就打算把這個部分搬到其他地方了。
// 保持模板的獨立性,使其易於管理和修改
export const unifiedPromptTemplate = `<role>
You are a world-class senior frontend technical interviewer providing a comprehensive evaluation.
</role>
<task>
Carefully analyze the user's answer based on the provided context. Your evaluation must be grounded in the evidence given.
- **If the question is conceptual (i.e., <judge0_result> contains 'not applicable for this question')**:
- Base your evaluation on how well the <user_answer> aligns with the key points in <rag_context>.
- The \`grounded_evidence\` field in your JSON response MUST be \`null\`.
- **If the question is a coding challenge (i.e., <rag_context> contains 'not applicable for this question')**:
- Base your evaluation strictly on the objective <judge0_result> and an analysis of the <user_answer> (which is user's code).
- The \`grounded_evidence\` field in your JSON response MUST be populated with data from the execution results.
Always refer to the <conversation_history> for dialogue context.
Your response MUST be a single, valid JSON object following the schema. Answer in Traditional Chinese.
</task>
<json_schema>
{
"summary": "string",
"score": "number (1-5)",
"grounded_evidence": { "tests_passed": "number|null", "tests_failed": "number|null", "stderr_excerpt": "string|null" } | null,
"pros": ["string"],
"cons": ["string"],
"next_practice": ["string"]
}
</json_schema>
<conversation_history>
\${formattedHistory}
</conversation_history>
<question>
\${question}
</question>
<rag_context>
\${ragContext}
</rag_context>
<judge0_result>
\${judge0Result}
</judge0_result>
<user_answer>
\${userAnswer}
</user_answer>`;
interface PromptContext {
formattedHistory: string;
question: string;
ragContext: string;
judge0Result: string;
userAnswer: string;
}
/**
* 根據上下文填充統一的 Prompt 模板。
* @param context 包含所有需要填充的資訊的物件
* @returns 填充完畢的最終 Prompt 字串
*/
export function buildUnifiedPrompt(context: PromptContext): string {
return unifiedPromptTemplate
.replace(/\${formattedHistory}/g, context.formattedHistory)
.replace(/\${question}/g, context.question)
.replace(/\${ragContext}/g, context.ragContext)
.replace(/\${judge0Result}/g, context.judge0Result)
.replace(/\${userAnswer}/g, context.userAnswer);
}
gemini.ts
最後一步則是把 Gemini 相關的函數整理到這個檔案中,包含向量與一般的模型請求,完整檔案的內容如下:
// app/lib/gemini.ts
import { GoogleGenAI, Content } from '@google/genai';
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
if (!GEMINI_API_KEY) {
throw new Error('GEMINI_API_KEY is not set in environment variables');
}
export const genAI = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
/**
* 生成文字的 Embedding 向量
* @param text 要轉換成向量的文字
* @returns 768 維的向量陣列
*/
export async function generateEmbedding(text: string): Promise<number[]> {
const embeddingResponse = await genAI.models.embedContent({
model: 'gemini-embedding-001',
contents: text,
config: {
outputDimensionality: 768,
},
});
if (
!embeddingResponse.embeddings ||
embeddingResponse.embeddings.length === 0
) {
throw new Error('Embedding response is empty');
}
return embeddingResponse.embeddings[0].values || [];
}
/**
* 使用 Gemini 生成串流回應
* @param prompt 完整的 prompt 文字
* @returns ReadableStream 用於串流回應
*/
export async function generateContentStream(
prompt: string
): Promise<ReadableStream> {
const contents: Content[] = [{ parts: [{ text: prompt }] }];
const result = await genAI.models.generateContentStream({
model: 'gemini-2.5-flash',
contents: contents,
config: {
responseMimeType: 'application/json',
},
});
return new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
for await (const chunk of result) {
const text = chunk.text;
if (text) {
controller.enqueue(encoder.encode(text));
}
}
controller.close();
},
});
}
/**
* 使用 Gemini 生成完整回應 (非串流)
* @param prompt 完整的 prompt 文字
* @returns 生成的文字內容
*/
export async function generateContent(prompt: string): Promise<string> {
const contents: Content[] = [{ parts: [{ text: prompt }] }];
const result = await genAI.models.generateContent({
model: 'gemini-2.5-flash',
contents: contents,
config: {
responseMimeType: 'application/json',
},
});
return result.text || '';
}
現在各個服務都拆得很清楚了,馬上修改evaluate/route.ts
的內容吧,檔案本身要修改的地方不少,最終的檔案會變為這樣,相較於之前少了不少內容,簡潔多了:
// app/api/interview/evaluate/route.ts
import { NextResponse } from 'next/server';
import questions from '@/data/questions.json';
import { formatChatHistory } from '@/app/lib/utils';
import { buildUnifiedPrompt } from '@/app/lib/prompt';
import { performRagSearch } from '@/app/lib/supabase';
import { generateEmbedding, generateContentStream } from '@/app/lib/gemini';
import { getFormattedJudge0Result } from '@/app/lib/judge0';
export async function POST(request: Request) {
try {
const { questionId, answer, history } = await request.json();
const question = questions.find((q) => q.id === questionId);
if (!question) {
return NextResponse.json(
{ error: 'Question not found' },
{ status: 404 }
);
}
// 準備所有需要的上下文變數
const formattedHistory = formatChatHistory(history);
let ragContext = 'not applicable for this question';
let judge0ResultText = 'not applicable for this question';
if (question.type === 'concept') {
// --- 概念題路徑 (RAG) ---
const answerEmbedding = await generateEmbedding(answer);
ragContext = await performRagSearch(answerEmbedding, questionId);
} else if (question.type === 'code') {
judge0ResultText = await getFormattedJudge0Result(answer);
}
// 填充統一的 Prompt 模板
const finalPrompt = buildUnifiedPrompt({
formattedHistory,
question: question.question,
ragContext,
judge0Result: judge0ResultText,
userAnswer: answer,
});
if (!finalPrompt) {
return NextResponse.json(
{ error: 'Invalid question type' },
{ status: 400 }
);
}
const stream = await generateContentStream(finalPrompt);
return new Response(stream, {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
} catch (error) {
console.error('Error in evaluation API:', error);
if (error instanceof Error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
別忘了app/api/judge0/execute/route.ts
檔案也要跟著修正。
// app/api/judge0/execute/route.ts
import { NextResponse } from 'next/server';
import { executeCode } from '@/app/lib/judge0';
export async function POST(request: Request) {
try {
const { source_code } = await request.json();
if (!source_code) {
return NextResponse.json({ error: '缺少 source_code' }, { status: 400 });
}
const result = await executeCode(source_code);
return NextResponse.json(result);
} catch (error) {
console.error('代理 /api/judge0/execute 錯誤:', error);
return NextResponse.json({ error: '代理伺服器內部錯誤' }, { status: 500 });
}
}
今天多做了一點工,但我們總算把路由的邏輯簡化了,就像我一開始說的,路由本身就不該包含太多邏輯,呼叫服務並回傳結果就行了,現在兩個核心的 API 路由都有著相當簡潔好懂的結構,做得很好~!放假啦!
✅ 建立了職責分明的 lib 資料夾,將通用函式 (utils)、各項服務 (supabase, judge0, gemini) 與 Prompt 模板 (prompt) 各歸其位。
✅ 成功將所有外部服務的核心邏輯抽離,變成了獨立、可重用、易於測試的服務模組。
✅ 將 /api/interview/evaluate 這個最核心的路由,重構成一個乾淨、易讀的「總指揮」,只負責協調各個服務,不再處理瑣碎的實作細節。
今天這場徹底的「程式碼大掃除」,正是為了迎接要進場的「新傢俱」——使用者系統。
我們的 AI 面試官現在很會面試,但它還不認識任何人,也記不住任何人的表現。所有的對話都是一次性的,這顯然不夠!
從明天 (Day 21) 開始,我們將進入專案的下一個重要階段:使用者系統與資料持久化。我們將會使用 Supabase Auth 來快速打造登入/註冊功能,並建立儲存使用者練習紀錄的資料表,讓每一次的面試成果都能被永久保存下來。
我們明天見!
今日程式碼: https://github.com/windate3411/Itiron-2025-code/tree/day-20